package com.wang.ddp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.wang.ddp.dto.Result;
import com.wang.ddp.entity.Shop;
import com.wang.ddp.mapper.ShopMapper;
import com.wang.ddp.service.IShopService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.wang.ddp.utils.CaCheClient;
import com.wang.ddp.utils.RedisData;
import com.wang.ddp.utils.SystemConstants;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.domain.geo.GeoReference;
import org.springframework.data.redis.domain.geo.GeoShape;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
@Transactional
public class ShopServiceImpl extends ServiceImpl<ShopMapper, Shop> implements IShopService {

    @Autowired
    StringRedisTemplate stringRedisTemplate;

    @Resource
    private CaCheClient caCheClient;

    //线程池
    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     * 根据id查询店铺
     * @param id
     * @return
     */
    @Override
    public Result getShopById(Long id) {
        //return Result.ok(queryWithPassThrough(id));

//        Shop shop = queryWithPassMutex(id);
//        Shop shop = queryWithPassLogicalExpire(id);
//        Shop shop = caCheClient.queryWithPassThrough("cache:shop:", id, Shop.class, this::getById, 30L, TimeUnit.MINUTES);
        Shop shop = caCheClient.queryWithPassLogicalExpire("cache:shop:", id, Shop.class, this::getById,600L,1L, TimeUnit.SECONDS);

        if (shop == null){
            return Result.fail("店铺不存在");
        }
        return Result.ok(shop);




    }

    /**
     * 解决缓存穿透，缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在，这样缓存永远不会生效，这些请求都会打到数据库
     * @param id
     * @return
     */
    public Shop queryWithPassThrough(Long id){
        String key = "cache:shop:"+id;
        //先从redis中查询数据
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
//        isNotBlank方法只有参数是非空字符串才是true，""、"\n\t"、null，这三种都返回false
        if (shopJson != null){
            //如果shopJson既不是非空字符串，又不是null，那么就是空字符串""或"\n\t"空白字符串了
            //这时候代表这个数据就是空数据，数据库中都没有的数据，直接返回
            return null;
        }
        //如果不存在,从数据库中查询
        Shop shop = this.getById(id);
        if (shop!=null){
            //在数据库中存在，则存入redis
            //存入redis中TTL设置一个随机值，防止大量key同时失效，导致大量请求访问数据库造成巨大压力，这种情况叫缓存雪崩
            long l = RandomUtil.randomLong(30,40);
            stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop),l , TimeUnit.MINUTES);
            return shop;
        }

        //如果数据库也不存在，就出现缓存穿透，存空字符串来解决
        stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES);
        return null;
    }

    /**
     * 互斥解决缓存击穿，缓存击穿问题也叫热点Key问题，就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了，
     * 无数的请求访问会在瞬间给数据库带来巨大的冲击。
     * @param id
     * @return
     */
    public Shop queryWithPassMutex(Long id) {
        String key = "cache:shop:"+id;
        //先从redis中查询数据
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isNotBlank(shopJson)){
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }
//        isNotBlank方法只有参数是非空字符串才是true，""、"\n\t"、null，这三种都返回false
        if (shopJson != null){
            //如果shopJson既不是非空字符串，又不是null，那么就是空字符串""或"\n\t"空白字符串了
            //这时候代表这个数据就是空数据，数据库中都没有的数据，直接返回
            return null;
        }
        String lockKey = "lock:shop:" + id;
        try {
            //如果不存在,从数据库中查询，查询数据库前要加互斥锁
            boolean isLock = tryLock(lockKey,50L);
            if (!isLock) {
                //如果锁存在，表示其他线程正在执行数据库查询，这个线程需要等待其他线程操作完、释放锁之后才能操作，这里需要休眠
                Thread.sleep(50L);
                //休眠完后再重新操作，这里递归,还有返回递归结果
                return this.queryWithPassMutex(id);
            }
            //如果锁不存在，表示我们这个线程是第一个执行数据库查询操作的
            Shop shop = this.getById(id);
            //模拟延时，200毫秒
            Thread.sleep(200L);
            if (shop != null) {
                //在数据库中存在，则存入redis
                //存入redis中TTL设置一个随机值，防止大量key同时失效，导致大量请求访问数据库造成巨大压力，这种情况叫缓存雪崩
                long l = RandomUtil.randomLong(30, 40);
                stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), l, TimeUnit.MINUTES);
                //存入redis后要记得释放锁
                unLock(lockKey);
                return shop;
            }

            //如果数据库也不存在，就出现缓存穿透，存空字符串来解决
            stringRedisTemplate.opsForValue().set(key, "", 2L, TimeUnit.MINUTES);
        } catch (InterruptedException e){
            e.printStackTrace();
        } finally {
            //无论有没有异常，都要释放锁
            unLock(lockKey);
        }
        return null;
    }

    /**
     * 采用逻辑删除来解决缓存击穿，逻辑删除实际上是不删除，不设置TTL，永久存在，自己来判断是否过期
     * @param id
     * @return
     */
    public Shop queryWithPassLogicalExpire(Long id){
        String key = "cache:shop:"+id;
        //先从redis中查询数据，如果没查到就直接返回null
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        if (StrUtil.isBlank(shopJson)){
            return null;
        }
        //此时查询的数据不在时Shop类型的了，反序列化成RedisData类型的
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        //date原本是Object类型，但是JSONUtil.toBean是把一个JSONObject转为指定类型，所以这里还需要强转一下
        JSONObject data = (JSONObject) redisData.getData();
        Shop shop = JSONUtil.toBean(data, Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();
        if (shop == null){
            return null;
        }
        /**因为缓存穿透是redis不知道MySQL中有没有数据才去查询MySQL，而缓存击穿是一个被高并发访问并且缓存重建业务较复杂的key突然失效了，
        逻辑删除其实数据是一直存在的，再数据查询前会做数据预热，即将数据库中一些热点数据，查询频繁的数据添加到redis，所以
        数据库中没有的热点数据，redis中也没有，redis中没有的热点数据，数据库中也没有，此时就没必要使用存空数据来解决缓存穿透了
        所以下面的判断没必要写了**/
//        if (shopJson.equals("")){
//            return null;
//        }
        /**如果从redis中查到数据，要判断数据是否过期，如果没过期，直接返回未过期的数据，但如果过期了
        需要尝试获取互斥锁，获取锁成功的话，开辟新的线程，去查询数据库中的数据并添加到redis中更新缓存
        注意，这是新线程干的活，旧线程不会等新线程干完活再去返回新数据，逻辑删除方案就是要快，它不管你数据是否是新的它都返回
        所以无论获取锁是否成功，它都返回旧数据，直到锁里面的线程干完活，更新完数据，下一个请求发来时，才会返回新数据**/

        //判断是否过期(判断expireTime是否是在当前时间之后，如果是，就是未过期
        if (expireTime.isAfter(LocalDateTime.now())){
            //未过期，直接返回
            return shop;
        }
        //已过期的情况

        //获取互斥锁
        String lockKey = "lock:shop:"+id;
        boolean flag = tryLock(lockKey,50L);
        if (!flag){
            //获取锁失败，表示有其他线程开辟新线程来更新数据，不管了，直接返回旧数据
            return shop;
        }
        //如果获取锁成功，表示当前没有其他线程和我抢锁，我就可以开辟新线程去更新缓存
        CACHE_REBUILD_EXECUTOR.submit(()->{
            try {
                //缓存重建
                this.saveShop(id,20L);
            } catch (Exception e) {
                throw new RuntimeException(e);
            } finally {
                //释放锁,释放锁是新线程干的活，旧线程返回开启锁返回旧数据就完了，这里细品，如果释放锁的操作给旧线程操作会怎么样？
                unLock(lockKey);
            }
        });
        return shop;
    }


    /**
     * 数据预热：保存带有过期时间的商铺数据到redis
     *
     * @param id
     * @param expireTime    过期时间，秒
     */
    public void saveShop(Long id, Long expireTime){
        RedisData redisData = new RedisData();
        redisData.setData(this.getById(id));
        //模拟休眠
        try {
            Thread.sleep(200L);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        //LocalDateTime.now()返回当前时间，和plusSeconds组合使用就是返回当前时间加上指定秒数
        //2023-04-20T15:10:42.884
        //2023-04-20T15:10:52.884
        //所以redisDate中的expireTime属性就是一个倒计时过期时间
        redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireTime));
        String jsonStr = JSONUtil.toJsonStr(redisData);
        //这里不设置过期时间
        stringRedisTemplate.opsForValue().set("cache:shop:"+id,jsonStr);
    }

    /**
     * 加锁操作
     * @param key
     * @return
     */
    public boolean tryLock(String key,Long ttl){
        //setIfAbsent是redis中的setnx命令，如果key存在就不插入数据，只有key不存在才插入数据，用它来实现互斥锁
        //如果加锁后执行一些操作出现异常，导致锁一直存在，陷入死锁，所以为锁加个存活时间作为兜底方案，一般时间为毫秒级别
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", ttl, TimeUnit.SECONDS);
        //因为返回值是boolean,而flag是Boolean，涉及到拆箱问题，如果flag是null有可能造成空指针异常，这里使用cn.hutool.core.util的工具包解决
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁操作
     * @param key
     */
    public void unLock(String key){
        stringRedisTemplate.delete(key);
    }

    /**
     * 根据id修改店铺，先更新数据库，再删除缓存
     * @param shop
     * @return
     */
    @Override
    public Result updateShopById(Shop shop) {
        if (shop.getId()==null){
            return Result.fail("店铺不存在");
        }
        //先更新数据库，再删除缓存
        this.updateById(shop);
        stringRedisTemplate.delete("cache:shop:"+shop.getId());
        return Result.ok();
    }

    @Override
    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {
        //如果没有传经纬度
        if (x == null || y == null){
            // 根据类型分页查询
            Page<Shop> page = this.query()
                    .eq("type_id", typeId)
                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
            // 返回数据
            return Result.ok(page.getRecords());
        }
        //如果传了经纬度,查询方式就发生改变，返回的shop信息要携带distance信息，就是距离当前位置多远
        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
        String key = "shop:geo:" + typeId.toString();
        //查询redis，按照距离排序，分页。结果：shopId、distance
        //redis的分页查询不像mysql那样，limit只能放一个数值类型，表示返回结果中前end个数据，[0,end)
        //也就是从下标0开始到下标end-1中所有的数据，想分页返回只能自己分割数据然后返回
        //GEOSEARCH key BYLONLAT x y BYRADIUS 5000 WITHDISTANCE
        GeoResults<RedisGeoCommands.GeoLocation<String>> search = stringRedisTemplate.opsForGeo().search(
                key,    //key
                GeoReference.fromCoordinate(x, y),   //圆心坐标
                new Distance(5000),           //圆心周围5000m
                RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs() //附带参数
                        .includeDistance()  //返回单位
                        .limit(end)     //分页，其实是一次查询从0到end的数据
        );
        //判空，如果为空返回空集合
        if (search == null){
            return Result.ok(Collections.emptyList());
        }
        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> geoResultList = search.getContent();
        //如果geoResultList的长度大小 <= from，表示没有下一页了，返回空集合
        if (geoResultList.size() <= from){
            return Result.ok(Collections.emptyList());
        }
        List<Long> shopIds = new ArrayList<>(geoResultList.size());
        Map<Long, Double> map = new HashMap<>();
        //分割数据，从下标为from的数据开始返回，由于这里下标从0开始，跳过from个数据，即从第from+1个数据开始返回，
        //而第from+1个数据的下标为from
        geoResultList.stream().skip(from).forEach(geoResult -> {
            String shopIdStr = geoResult.getContent().getName();
            Long shopId = Long.valueOf(shopIdStr);
            shopIds.add(Long.valueOf(shopIdStr));
            Distance distance = geoResult.getDistance();
            map.put(shopId,distance.getValue());
        });
        String shopIdStr = StrUtil.join(",", shopIds);
        List<Shop> shops = this.query().in("id", shopIds).last("ORDER BY FIELD(id," + shopIdStr + ")").list();
        shops = shops.stream().map((shop -> {
            return shop.setDistance(map.get(shop.getId()));
        })).collect(Collectors.toList());
        return Result.ok(shops);
    }
}
